谈谈C++的volatile关键字以及常见的误解
近期看到C++标准中对volatile关键字的定义,发现和java的volatile关键字完全不一样,C++的volatile对并发编程基本没有帮助。网上也看到很多关于volatile的误解,于是决定写这篇文章详细解释一下volatile的作用到底是什么。
编译器对代码的优化
在讲volatile关键字之前,先讲一下编译器的优化。
int main() { int i = 0; i++; cout << "hello world" << endl; }
按照代码,这个程序会在内存中预留int大小的空间,初始化这段内存为0,然后这段内存中的数据加1,最后输出“hello world”到标准输出中。但是根据这段代码编译出来的程序(加-O2选项),不会预留int大小的内存空间,更不会对内存中的数字加1。他只会输出“hello world”到标准输出中。
其实不难理解,这个是编译器为了优化代码,修改了程序的逻辑。实际上C++标准是允许写出来的代码和实际生成的程序不一致的。虽说优化代码是件好事情,但是也不能让编译器任意修改程序逻辑,不然的话我们没办法写可靠的程序了。所以C++对这种逻辑的改写是有限制的,这个限制就是在编译器修改逻辑后,程序对外界的IO依旧是不变的。怎么理解呢?实际上我们可以把我们写出来的程序看做是一个黑匣子,如果按照相同的顺序输入相同的输入,他就每次都会以同样的顺序给出同样的输出。这里的输入输出包括了标准输入输出、文件系统、网络IO、甚至一些system call等等,所有程序外部的事物都包含在内。所以对于程序使用者来说,只要两个黑匣子的输入输出是完全一致的,那么这两个黑匣子是一致的,所以编译器可以在这个限制下任意改写程序的逻辑。这个规则又叫as-if原则。
volatile关键字的作用
不知道有没有注意到,刚刚提到输入输出的时候,并没有提到内存,事实上,程序对自己内存的操作不属于外部的输入输出。这也是为什么在上述例子中,编译器可以去除对i变量的操作。但是这又会出现一个麻烦,有些时候操作系统会把一些硬件映射到内存上,让程序通过对内存的操作来操作这个硬件,比如说把磁盘空间映射到内存中。那么对这部分内存的操作实际上就属于对程序外部的输入输出了。对这部分内存的操作是不能随便修改顺序的,更不能忽略。这个时候volatile就可以派上用场了。按照C++标准,对于glvalue的volatile变量进行操作,与其他输入输出一样,顺序和内容都是不能改变的。这个结果就像是把对volatile的操作看做程序外部的输入输出一样。(glvalue是值类别的一种,简单说就是内存上分配有空间的对象,更详细的请看我的另一篇文章。)
按照C++标准,这是volatile唯一的功能,但是在一些编译器(如,MSVC)中,volatile还有线程同步的功能,但这就是编译器自己的拓展了,并不能跨平台应用。
对volatile常见的误解
实际上“volatile可以在线程间同步”也是比较常见的误解。比如以下的例子:
class AObject { public: void wait() { m_flag = false; while (!m_flag) { this_thread::sleep(1000ms); } } void notify() { m_flag = true; } private: volatile bool m_flag; }; AObject obj; ... // Thread 1 ... obj.wait(); ... // Thread 2 ... obj.notify(); ...
对volatile有误解的人,或者对并发编程不了解的人可能会觉得这段逻辑没什么问题,可能会认为volatile保证了,wait()对m_flag的读取,notify()对m_flag的写入,所以Thread 1能够正常醒来。实际上并不是这么简单,因为在多核CPU中,每个CPU都有自己的缓存。缓存中存有一部分内存中的数据,CPU要对内存读取与存储的时候都会先去操作缓存,而不会直接对内存进行操作。所以多个CPU“看到”的内存中的数据是不一样的,这个叫做内存可见性问题(memory visibility)。放到例子中就是,Thread 2修改了m_flag对应的内存,但是Thread 1在其他CPU核上运行,所以Thread 1不一定能看到Thread 2对m_flag做的更改。C++11开始,C++标准中有了线程的概念,C++标准规定了什么情况下一个线程一定可以看到另一个线程做的内存的修改。而根据标准,上述例子中的Thread 1可能永远看不到m_flag变成true,更严重的是,Thread 1对m_flag的读取会导致Undefined Behavior。
从C++标准来说,这段代码是Undefined Behavior,既然是Undefined Behavior的话,是不是也可能正确执行?是的,熟悉MESI的应该会知道,Thread 2的修改导致缓存变脏,Thread 1读取内存会试图获取最新的数据,所以这段代码可以正常执行。那是不是就意味着我们可以放心使用volatile来做线程的同步?不是的,只是在这个例子能够正确执行而已。我们对例子稍作修改,volatile就没那么好使了。
class AObject { public: void wait() { m_flag = false; while (!m_flag) { this_thread::sleep(1000ms); } } void notify() { m_flag = true; } private: volatile bool m_flag; }; AObject obj; bool something = false; ... // Thread 1 ... obj.wait(); assert(something) ... // Thread 2 ... something = true; obj.notify(); ...
在以上代码中,Thread 1的assert语句可能会失败。就如前文所说,C++编译器在保证as-if原则下可以随意打乱变量赋值的顺序,甚至移除某个变量。所以上述例子中的“something = true"语句可能发生在obj.notify()之后。这样的话,“assert(something)”就会失败了。
那么我们可不可能把something也变成volatile?如果something是volatile,我们确实能够保证编译出来的程序中的语句顺序和源代码一致,但我们仍然不能保证两个语句是按照源代码中的顺序执行,因为现代CPU往往都有乱序执行的功能。所谓乱序执行,CPU会在保证代码正确执行的基础上,调整指令的顺序,加快程序的运算,更多细节我们不在这里展开。我们如果单看Thread 2线程,something和m_flag这两个变量的读写是没有依赖关系的,而Thread 2线程看不到这两个变量在其他线程上的依赖关系,所以CPU可能会打乱他们的执行顺序,或者同时执行这两个指令。结果就是,在Thread 1中,obj.wait()返回后,something可能仍然是false,assert失败。当然,会不会出现这样的状况,实际上也和具体的CPU有关系。但是我们知道错误的代码可能会引起错误的结果,我们应该避免错误的写法,而这个错误就在于误用了volatile关键字,volatile可以避免优化、强制内存读取的顺序,但是volatile并没有线程同步的语义,C++标准并不能保证它在多线程情况的正确性。
那么用不了volatile,我们该怎么修改上面的例子?C++11开始有一个很好用的库,那就是atomic类模板,在<atomic>头文件中,多个线程对atomic对象进行访问是安全的,并且提供不同种类的线程同步。不同种类的线程同步非常复杂,要涉及到C++的内存模型与并发编程,我就不在此展开。它默认使用的是最强的同步,所以我们就使用默认的就好。以下为修改后的代码:
class AObject { public: void wait() { m_flag = false; while (!m_flag) { this_thread::sleep(1000ms); } } void notify() { m_flag = true; } private: atomic<bool> m_flag; };
只要把“volatile bool”替换为“atomic<bool>”就可以。<atomic>头文件也定义了若干常用的别名,例如“atomic<bool>”就可以替换为“atomic_bool”。atomic模板重载了常用的运算符,所以atomic<bool>使用起来和普通的bool变量差别不大。